現代的3D遊戲引擎,講究的是眾多製作模型動畫的工具,以及即時計算光影、繪製場景的軟硬體,建模型、套骨骼、拉動作、打燈光、架攝影機...每個環節都是一份全職的工作,更不用說,要在程式裏操作3D遊戲引擎所需要掌握的知識與經驗,那絕不是一天兩天的事。
但我今天就想要做一個3D遊戲呀!有什麼辦法嗎?
有的,偽3D就可以滿足我們的心願,只靠2D遊戲的技術,營造3D遊戲的景深與空間感。
偽3D的英文是Fake 3D,或稱Pseudo 3D,指的是不使用立體模型,純粹以操作平面圖片的縮放與調整位置的方式,讓物體在畫面上移動時符合立體空間的透視規則,藉此來呈現3D遊戲的感覺。
上圖是《怪朋友Online》的遊戲畫面,寵物居住的地方是以純2D圖案組成的小房間,不但寵物在這兒能夠以3D空間的方式活動,而且所有的傢俱都能在牆上、地板、天花板讓玩家自由移動擺飾。這個遊戲的核心開發人員只有一位2D美術設計師,以及小哈本人我。
這樣一看,同學是不是開始對偽3D產生了好奇心,到底這個技術的內涵是什麼呢?
其實偽3D的基本概念很簡單,就是用一個轉換公式,將一個將空間座標經過攝影機投射在感光片(螢幕)。
上圖是攝影機與南瓜頭在3D空間的示意圖,其中綠色的四角錐是攝影機在3D空間中的可視範圍,也就是說,任何落在綠色四角錐內的物體都會投射在攝影機內負責感光的螢幕上。
想要偽3D,首先要搞懂一個3D空間的座標怎麼轉變成螢幕上的2D座標。我們拿上圖的南瓜頭為例,雖然南瓜頭是個正方體,但實際上在遊戲中只會以一張圖片來表示,也就是南瓜頭的臉,那麼南瓜臉所在的平面,也就是上圖平行於螢幕的景深平面,在投射至螢幕平面時,可以很清楚地看到是個等比例縮小的概念。
我們計算座標的基準點是南瓜臉的中心,從攝影機拉一條青色的線,連到南瓜臉的中心,會穿過代表螢幕的平面,而穿過螢幕的這個點,就是南瓜臉的中心在螢幕上應該出現的位置。
如果南瓜臉中心相對於攝影機位置的座標是 ,那麼這個座標投射在螢幕上的位置 該怎麼算,這道題目就是整個偽3D技術的核心。
我們先從這空間的正上方往下看,省去Y軸,只看X軸和Z軸。只要我們搞懂X軸的位置變換,那麼以同樣的道理就可以得出Y軸的位置變換。從圖中可以發現,整個黑色的景深平面在投射至螢幕平面時是依等比例縮小,假設這個比例是R,那麼南瓜臉的中心距離Z軸的距離,以及南瓜臉最後投射在螢幕上距離Z軸的距離也會是同一個比例R。
從上圖可以看出綠色的大直角三角形和黑色虛線圍起來的小三角形是相似三角形,所以這兩個三角形的三個邊對比起來都是等比例的。由此可得下面這個比例公式。
也就是說,整個南瓜臉所在的景深平面,以 這個比例縮小後,就可以完美合轍地扣進螢幕。其中 是個攝影機的可調參數,表示攝影機離投射螢幕的距離,也就是所謂的焦距,焦距越小,攝影機的可視角度越大,焦距越大則相反,可視角度就越小。有了這層概念後,要將3D座標轉換成2D就簡單了,我們把公式寫下在面。
已知空間座標:
投射至螢幕的縮放率
於是螢幕座標就會等於
由於整個影深平面都要等比例縮小比例R, 所以南瓜臉在螢幕上的圖片大小同樣也要乘上比例R。
我們將上述偽3D的原理寫成函式庫。首先定義一個3D座標的類別。
/** 3D向量 */
class Vector3D {
constructor(
public x: number,
public y: number,
public z: number
) { }
}
類別中存有最重要的座標,也就是三個軸的位置。接著再加上向量裏最重要的兩個函式,用來計算向量長度,以及將向量正規化的功能。
class Vector3D {
...
/** 計算向量的長度 */
get length(): number {
return Math.sqrt(
this.x * this.x +
this.y * this.y +
this.z * this.z
);
}
/** 將向量調整至指定長度 */
get normalize(len = 1): number {
let scale = len / this.length;
this.x *= scale;
this.y *= scale;
this.z *= scale;
}
}
最後再加兩個工具函式,用來對向量作加法和減法。
class Vector3D {
...
/** 計算加上另一個向量的結果 */
add(other: Vector3D): Vector3D {
return new Vector3D(
other.x + this.x,
other.y + this.y,
other.z + this.z
);
}
/** 計算減去另一個向量的結果 */
sub(other: Vector3D): Vector3D {
return new Vector3D(
other.x - this.x,
other.y - this.y,
other.z - this.z
);
}
}
接著寫攝影機的類別,我們將其繼承向量類別。對類別的繼承不熟的同學,可以跳到本章最後對繼承的註解。
/** 三維攝影機類別 */
class Camera3D extends Vector3D {
/** 預設焦距為 10 */
focalLength = 10
}
攝影機其實也就是個3D空間裏的位置,不過再多加了焦距的設定,我們先預設焦距為10。這邊要先給一點前提:攝影機必須直視Z軸的方向,因此若物體的位置相對於攝影機的Z座標小於焦距,就不需要在畫面上出現,因為代表物體是在攝影機的後面,或是在攝影機與畫面的中間。
這篇文章設計的是固定方向,無法轉動的攝影機。未來我們也可以讓攝影機更自由,讓它能夠在空間中旋轉,不過如此一來就需要運用四元數(Quaternion)的矩陣運算,這就讓整個概念複雜化,偏離了我們今天就要偽3D的願望,所以這裏先跳過旋轉,以製作一個簡單高效的偽3D引擎為目標。
接著為攝影機寫個計算景深平面比例的工具函式。
class Camera3D extends Vector3D {
...
toScreenRate(relativeZ: number): number {
if(relativeZ == 0) {
return Number.MAX_SAFE_INTEGER;
} else {
let rate = this.focalLength / relativeZ;
}
}
}
函式的參數relativeZ是指Z軸上景深平面相對於攝影機所在位置的距離。如果relativeZ為0,代表景深平面在Z軸上恰好等於攝影機所在的位置,此時平面上所有的東西在畫面上應該都要是無限大。函式中回傳一個最大的安全整數,防止遊戲運算錯誤,不過實際在執行遊戲時,這種情況下應該讓物體隱藏起來。
然後再寫個轉換三維座標至畫面座標的工具函式。
class Camera3D extends Vector3D {
...
vector3DtoScreen(vector: Vector3D): Point {
// 計算相對於攝影機位置的相對座標
let relative = vector.sub(this);
// 取得縮放率
let rate = this.toScreenRate(relative.z);
// 計算投射至畫面上的2D座標
return new Point(
relative.x * rate,
relative.y * rate
)
}
}
由於示範程式都是以Pixi.js作為繪圖工具,所以我們最後寫個函式,將一張存在於3D空間中的Pixi圖片,正確地調整至畫面上應有的位置與大小。
class Camera3D extends Vector3D {
...
updateDisplay(vector: Vector3D, disp: PIXI.DisplayObject) {
// 計算相對於攝影機位置的相對座標
let relative = vector.sub(this);
// 檢查物體是不是在可視距離(即焦距外)
if(relative.z >= this.focalLength) {
// 取得縮放比例
let rate = this.toScreenRate(relative.z);
// 更新圖片在畫面上的位置及縮放比例
disp.visible = true;
disp.x = relative.x * rate;
disp.y = relative.y * rate;
disp.scale.set(rate);
} else {
// 不可視
disp.visible = false;
}
}
}
上面這個調整圖片位置與大小的函式,應該就算是整個偽3D的核心運算了。我們取得座標相對於攝影機位置的相對座標(relative),然後用相對座標的z值確定物體所在的景深平面位於焦距之外,接著再求得景深平面投射到畫面上的縮放比例(rate),最後把相對位置以比例調整,並依比例縮小圖片,這樣就會讓圖片看起來像是在3D空間的正確位置了。
示範程式中,我們將攝影機與焦距之間的物件隱藏起來,這是一般偽3D場景正常的作法。不過也有人會將這個區間的物件以半透明的方式顯示出來,也就是說,當物件在焦距上的不透明度(alpha)為1,越靠近攝影機,不透明度越低,這樣可以讓物件在非常靠近攝影機時,不會突然消失,而有個淡出的緩衝區。
示範程式( https://fake3d.gamelet.online )中有兩個小程式,分別是《宇宙星空》與《南瓜冒險》,同樣都是運用了這個簡單的偽3D引擎製作出來的。
示範程式的原始碼 https://code.gamelet.com/edit/fake3d
影片 https://youtu.be/J9KnRrLeV6c
在《宇宙星空》中,所有星星在宇宙空間都是不動的,唯一會動的是攝影機。遊戲中的攝影機會持續朝Z軸的方向飛,玩家可以移動滑鼠來改變攝影機在X軸與Y軸方向的速度,也可以利用方向鍵改變Z軸飛行的速度。同學可以發現星星在畫面上放大的速度,會呈現3D立體的效果,在遠方的變化量很小,等到靠近攝影機時,會快速放大。
在《南瓜冒險》中,同樣也可以用滑鼠控制攝影機的位置,用方向鍵控制南瓜頭在3D空間中移動,並且還能按空白鍵讓南瓜頭進行跳躍。
這些程式的規模雖然小,但同學已不難想像,利用偽3D的技術,就能如何速寫一個酷酷的類3D遊戲了吧!
請問你是用什麼工具或語言開發遊戲的呢?
目前主要是在 https://code.gamelet.com 上寫小遊戲。
這個平台用的是 Typescript / React 。
不過這個只能寫網頁遊戲。
最近在學React,有空也來研究看看,謝謝~